Skip to content

Host the Qwen3.8-Flash CUDA-graph decode loop in the Engine's static path - #2728

Open
Tianlei Wu (tianleiwu) wants to merge 17 commits into
tlwu/20261013/ga-b3-qwen38-graph-modelfrom
tlwu/20261013/ga-b4-qwen38-engine-host
Open

Tianlei Wu (tianleiwu) wants to merge 17 commits into
tlwu/20261013/ga-b3-qwen38-graph-modelfrom
tlwu/20261013/ga-b4-qwen38-engine-host

Conversation

@tianleiwu

@tianleiwu Tianlei Wu (tianleiwu) commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #2727 (base tlwu/20261013/ga-b3-qwen38-graph-model; the diff shows only this change). Position 3 of the series described in #2726. Retarget to main once #2727 merges.

Description

Serves the qwen38_flash_graph model type (#2727) through the Engine's existing static path. The CUDA-graph decode loop (the MTP draft -> verify -> commit cycle, the folded cycle, the dense fast prefill and the shared weight pool) stays on the device; the Engine keeps ownership of requests, turns, cancellation, stop conditions and speculative statistics. It is a single-slot backend: one resident request, greedy decoding, no prefix cache; further requests queue behind it in the static batch scheduler. Multi-slot hosting comes with ragged batching later in the series.

Multi-draft cycles of 2 to 4 tokens (S=2..4) are reported through Qwen38StepResult::tokens and the speculative statistics. The prompt length of a turn is handed to the device with Qwen38DecodeDevice::ExpectPrefillTokens (after the device Reset(), before the first prompt chunk), so QMoE memory lending can size the prefill budget from state that belongs to that device; the executor itself neither reads nor writes the process environment (ORT_QMOE_PREFILL_TOTAL_TOKENS is published by the device only around a single run). The prompt chunk size comes from EffectiveQwen38PrefillChunkTokens of the reworked qwen38_flash_graph configuration (#2727).

Summary of Changes

File Change
src/engine/qwen38_graph_executor.{h,cpp} Qwen38GraphCacheManager (static-cache contract for one row), Qwen38GraphExecutor (a ModelExecutor that selects its own tokens: one Engine step is one prompt chunk of the size the device asks for, or one whole decode cycle) and Qwen38GraphSession (platform- and CUDA-independent logic between the executor and a Qwen38DecodeDevice; checks the device error bits when a turn ends; continuation and rewind re-ingest the history through the same chunked prefill; the speculative statistics count the drafts that were actually evaluated and classify rounds by the drafts they delivered, as Engine::RecordSpeculativeCommit does).
src/engine/engine.cpp, model_executor.h, cache_manager.h Hooks, all defaulting to the previous behaviour for other models: turn validation before any Request mutation (sampling, repetition penalty, no-repeat n-grams, minimum token counts, seeds, guidance and oversized sessions are rejected with a clear error), speculative-statistics reporting, static batch and length limits, and CreateDependencies returning early for this model type before any IndexShare/MTP setup.
src/engine/request.{h,cpp}, scheduled_requests.{h,cpp} Request::CommitExternalTokens applies EOS, length limits and stop strings per committed token (precedence: stop string, EOS, turn limit, context limit; later tokens of a cycle are dropped); ScheduledRequests stages the executor's tokens. Stop strings are accepted on this static path only.
docs/paged_attention_engine.md New section on the static single-slot backend that selects its own tokens: chunking, ExpectPrefillTokens, the Drain() + CheckDecodeStatus() contract and the speculative statistics.
test/cpp/engine/qwen38_graph_executor_tests.cpp, qwen38_graph_test_doubles.h 59 tests (plus one disabled benchmark) against a scripted Qwen38DecodeDevice and the real Engine: dense chunk streaming (any size, shorter than or exactly the chunk, hand-off exactly once and only after the last chunk), hand-off skipped when the first token ends the turn, cancellation between chunks and during decode, stop strings, EOS, continuation and rewind, all-rejected and all-accepted cycles, device errors (including failures that only show after Drain()), capability errors, evaluated-draft and round counts of truncated rounds, and the expected prefill total of two Engines on two owner threads. The scripted device completes cycles asynchronously like the real one (Drain() leaves the record in flight, CheckDecodeStatus() reports the accumulated status bits, Reset() clears them).

The dynamic/paged Engine path and the MTP path added by #2714 are not changed: the new Request/ScheduledRequests entry points are only reached from this executor, which never attaches drafts (MaxDraftTokensPerStep() == 0).

Review feedback incorporated

  • Qwen38GraphSession::Release() and Begin() propagate an unchecked device decode status error (and capture a deferred teardown error), so a turn that ends through a stop string, cancellation, rewind or replacement still checks the device validation bits before Reset().
  • The committed RequestStepResult is fed back to Qwen38GraphSession::OnStepCommitted() after Request::CommitExternalTokens, so tokens_emitted, tokens_discarded and the accepted and bonus counts reflect the tokens that were actually committed. Tests cover EOS, stop-string and canceled turns.
  • A device failure that only becomes visible after Drain() (the look-ahead cycle that is completed when a turn is canceled, ends on a stop string or is rewound) fails the request instead of being cleared by the next Reset(). Every such path runs Drain() and then CheckDecodeStatus(), which on the reworked device contract (Add the qwen38_flash_graph model type and its device decode engine #2727) reads the status bits accumulated since the last Reset() even when a finished cycle record is left unconsumed, so no executor change was needed; the scripted device now behaves like the real one and AFailureOfTheLookAheadCycleVisibleOnlyAfterDrain* / AFailureOfTheFirstCycleVisibleOnlyAfterDrainSurfacesAfterACancellation cover it.
  • The evaluated-draft count no longer over-counts a truncated round: it is the accepted-prefix positions the turn consumed plus the rejected position only when the round reached it (three accepted drafts with room for one token: 1 evaluated and 1 accepted, not 2 evaluated). Turn and session limits, stop strings and EOS never add a position beyond the boundary.
  • Rounds are classified as zero, partial or full accept by the number of drafts the turn took from the round, partial_accept_rounds is counted for every partial round, and AddStats merges it.
  • The expected prefill total is state of the device (ExpectPrefillTokens), not of the process: the executor no longer calls setenv. EnginesOnTwoOwnerThreadsKeepTheirOwnExpectedPrefillTotals runs two Engines on two owner threads with their own scripted devices and checks that each device sees its own total in every chunk of a turn (first turn, continued turn, canceled turn, following request) and that ORT_QMOE_PREFILL_TOTAL_TOKENS is untouched.

Testing

  • CUDA build (-DUSE_CUDA=ON -DENABLE_QWEN38_PLANE=ON, CUDA 13.0, H200/sm_90) on top of the reworked Add the qwen38_flash_graph model type and its device decode engine #2727 (85595784): engine_unit_tests --gtest_filter='*Qwen38*' 135 passed, 0 failed. The full binary runs 1249 tests: 1209 passed, 22 skipped, 18 failed, and the 18 are exactly the failures of the unmodified base in this environment (the Cuda* provider-bridge tests need GetProvider from the in-tree CUDA EP, which the rc5 plugin EP used here does not export; the three ModelPackage.* tests (TokenizerResolvesThroughSharedAsset, ExternalInitializersFolderResolvesThroughSharedAsset, FlatDirectoryRejectsSha256Reference) fail identically on unmodified main and are unrelated to this change).
  • The sources of this PR compile as C++17 with -DUSE_CXX17=1 (the Android configuration) and as C++20 with -Wall -Wextra without diagnostics.
  • GPU qualification of this code through og.Engine (original head of this PR, H200, v12 package): token identity against the reference runner 6/6 (EOS mode) and 6/6 (fixed length) with equal cycle and accept counts, MTP stream equal to the no-MTP stream 6/6, and engine_behaviours.py 27/27 in the bit-exact (--prefill steps) and the fast-prefill modes. The tip of the whole stack was re-qualified end to end on the rc5 package (see the final PR of the runtime series, [Qwen38] Adopt the SpeculativePrefixCommit contract of ORT #33474, replay conv states of both layouts, and fix the prefix-commit slot stride #2824).
  • Not run: Windows/macOS, multi-GPU, sampling (rejected by design).

Motivation and Context

The Generator-hosted version of this loop could not share the Engine's request lifecycle (turns, cancellation, stop strings, statistics, queueing), which is what the serving stack uses. Hosting it as a static-path executor reuses all of that while keeping the single-slot device state (fixed in-place KV, recurrent-state bank ring) that cannot be paged. It is an alternative backend next to the paged Engine MTP path of #2714, not a replacement.

Limits of this PR: greedy only; one resident request; no prefix cache and no caller drafts; a continued turn or the replay after RewindToStartOfTurn re-ingests the whole history (0.3 s at 2k, 3.2 s at 32k with the fast prefill); sessions up to kv_capacity - 4 tokens; Linux + CUDA only. The GPU gates need an exported package, the CUDA EP and the contrib operators, which CI does not have, so CI covers the scripted-device tests only.

Checklist

  • Tests added/updated
  • No breaking changes (hooks default to the previous behaviour)
  • Documentation updated

Tianlei Wu (tianleiwu) added a commit that referenced this pull request Oct 14, 2026
…th auxiliary sessions (#2725)

## Description

Adds `session_options.custom_ops_libraries`, a list of custom-op
libraries registered in order after the existing single
`custom_ops_library`, and exports `InheritSessionOptionsFromParent` so
components that create auxiliary sessions (for example MTP/draft heads)
can inherit the decoder's providers, provider options, config entries
and custom ops.

Models whose graphs use several custom-op domains (fused attention,
hyper-connection, GEMM and head ops shipped next to the model package)
currently cannot name more than one library. This change is generic and
has no dependency on any model type; it is the first step of the
Qwen3.8-Flash static-graph backend series (see the end of this
description), but stands alone and targets `main`.

## Summary of Changes

| File | Change |
|------|--------|
| `src/config.h`, `src/config.cpp` | New
`session_options.custom_ops_libraries` (JSON string array; scalar values
such as `"custom_ops_libraries": "ops.so"` are rejected during parsing).
Overlays append to `custom_ops_libraries` (and `[]` leaves existing
entries unchanged). `InheritSessionOptionsFromParent` exposes the
existing inheritance helper; `custom_ops_library` and
`custom_ops_libraries` are inherited together when the child sets
neither (note that `"custom_ops_libraries": []` leaves the vector empty
and therefore still inherits from the parent unless `custom_ops_library`
is set). |
| `src/models/model.cpp` | `custom_ops_library` and then every
`custom_ops_libraries` entry go through the same path-traversal and
allow-list checks, with duplicate resolved paths registered once. All
paths are validated against the allow-list before loading starts;
validated libraries are then loaded and registered sequentially in order
(so a later load/registration error can still leave earlier libraries
registered on `session_options`). |
| `test/CMakeLists.txt`,
`test/cpp/engine/custom_ops_test_lib/custom_ops_test_lib.cpp`,
`test/cpp/engine/custom_ops_libraries_tests.cpp` | Builds two small test
custom-op shared libraries (`custom_ops_test_first` and
`custom_ops_test_second`) and tests parsing/ordering, overlay rules,
rejection of non-string entries and scalar values, inheritance
(including `[]` and `CreateMtpDecoderConfig`), allow-list validation,
and actual library loading, ordering, and deduplication. |

### Library Location Requirement

Every entry in `custom_ops_library` and `custom_ops_libraries` must
resolve to an existing file inside one of the allowed directories:
1. The model folder (alongside `genai_config.json`),
2. A registered Execution Provider (EP) library directory, or
3. The current working directory.

Previously, an unresolved bare library name in `custom_ops_library` was
passed directly to `RegisterCustomOpsLibrary` and could be resolved
through the platform loader search path (`LD_LIBRARY_PATH` / `PATH`).
Unresolved entries are now rejected before loading so the directory
allow-list cannot be bypassed; users should place custom-op libraries in
one of the allowed directories above (typically the model folder).

## Testing

- CPU-only Release build (`-DUSE_CUDA=OFF`) on `main`:
`engine_unit_tests` **929 passed, 0 failed, 2 skipped** (including all 9
tests in `CustomOpsLibrariesConfigTest`, which load real test custom-op
shared libraries to verify registration, ordering, and deduplication).
- `lintrunner`, `clang-format` (20.1.8) and `git diff --check` are
clean.

## Motivation and Context

Found while hosting a model that uses seven custom-op libraries.
Registering them through one `custom_ops_library` entry was not
possible, and auxiliary sessions did not inherit the decoder's custom
ops, so head graphs failed to load without repeating the whole session
configuration.

This is the first PR of a stack that adds an Engine-hosted CUDA-graph
MTP backend for Qwen3.8-Flash-Next (greedy only, one resident request,
Linux/CUDA, no prefix cache). The follow-ups are drafts stacked on
`kvaishnavi/qwen-38-flash-run`, so this commit is also carried as the
first commit of the next PR (#2726) until this one merges. The stack:
#2726 (CUDA data plane library) -> #2727 (model type and device engine)
-> #2728 (Engine hosting) -> #2729 (qualification tools).

## Checklist

- [x] Tests added/updated
- [x] No breaking changes
- [ ] Documentation updated (the option is described in the PR; no
dedicated config documentation exists for `session_options` yet)

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tianleiwu
Tianlei Wu (tianleiwu) force-pushed the tlwu/20261013/ga-b4-qwen38-engine-host branch from 7463063 to 9a9a946 Compare October 15, 2026 04:48
@tianleiwu
Tianlei Wu (tianleiwu) force-pushed the tlwu/20261013/ga-b4-qwen38-engine-host branch 2 times, most recently from be1c6f9 to 4719a3f Compare October 19, 2026 08:57
Tianlei Wu (tianleiwu) added a commit that referenced this pull request Oct 30, 2026
…culative stats

- Propagate unchecked device decode status errors in Qwen38GraphSession::Release() and Begin() (and capture deferred teardown errors) so turns ending via stop strings, cancellation, rewind, or replacement still check device validation bits before Reset().

- Feed the committed RequestStepResult back to Qwen38GraphSession::OnStepCommitted() after Request::CommitExternalTokens so tokens_emitted, tokens_discarded, and accepted/bonus counts reflect actual committed tokens.

- Add unit tests and speculative-stats assertions for EOS, stop-string, and canceled turns.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tianleiwu
Tianlei Wu (tianleiwu) force-pushed the tlwu/20261013/ga-b4-qwen38-engine-host branch from 4719a3f to 17b0299 Compare October 30, 2026 05:12
@tianleiwu

Tianlei Wu (tianleiwu) commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Rebased onto the new head of #2727 and force-pushed (4719a3f05 -> e864af904). The description was rewritten to match the re-cut series; the series map is in #2726. The head includes a whitespace-only clang-format pass to match the CI's clang-format 20. Re-based again onto the amended #2726 (Windows rooted-path fix); the change of this PR is the same.

Tianlei Wu (tianleiwu) added a commit that referenced this pull request Oct 30, 2026
…culative stats

- Propagate unchecked device decode status errors in Qwen38GraphSession::Release() and Begin() (and capture deferred teardown errors) so turns ending via stop strings, cancellation, rewind, or replacement still check device validation bits before Reset().

- Feed the committed RequestStepResult back to Qwen38GraphSession::OnStepCommitted() after Request::CommitExternalTokens so tokens_emitted, tokens_discarded, and accepted/bonus counts reflect actual committed tokens.

- Add unit tests and speculative-stats assertions for EOS, stop-string, and canceled turns.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tianleiwu
Tianlei Wu (tianleiwu) force-pushed the tlwu/20261013/ga-b4-qwen38-engine-host branch from 17b0299 to 083043a Compare October 30, 2026 05:44
Tianlei Wu (tianleiwu) added a commit that referenced this pull request Oct 30, 2026
…culative stats

- Propagate unchecked device decode status errors in Qwen38GraphSession::Release() and Begin() (and capture deferred teardown errors) so turns ending via stop strings, cancellation, rewind, or replacement still check device validation bits before Reset().

- Feed the committed RequestStepResult back to Qwen38GraphSession::OnStepCommitted() after Request::CommitExternalTokens so tokens_emitted, tokens_discarded, and accepted/bonus counts reflect actual committed tokens.

- Add unit tests and speculative-stats assertions for EOS, stop-string, and canceled turns.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tianleiwu
Tianlei Wu (tianleiwu) force-pushed the tlwu/20261013/ga-b4-qwen38-engine-host branch from 083043a to e864af9 Compare October 30, 2026 06:24
@tianleiwu
Tianlei Wu (tianleiwu) marked this pull request as ready for review October 30, 2026 18:34
Copilot AI balanced review requested due to automatic review settings October 30, 2026 18:34
Tianlei Wu (tianleiwu) added a commit that referenced this pull request Nov 1, 2026
…culative stats

- Propagate unchecked device decode status errors in Qwen38GraphSession::Release() and Begin() (and capture deferred teardown errors) so turns ending via stop strings, cancellation, rewind, or replacement still check device validation bits before Reset().

- Feed the committed RequestStepResult back to Qwen38GraphSession::OnStepCommitted() after Request::CommitExternalTokens so tokens_emitted, tokens_discarded, and accepted/bonus counts reflect actual committed tokens.

- Add unit tests and speculative-stats assertions for EOS, stop-string, and canceled turns.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tianlei Wu (tianleiwu) added a commit that referenced this pull request Nov 1, 2026
The wasted look-ahead cycle of a stop-string or canceled turn is never
waited for, so its record stays unconsumed and only the device status bits
can tell that it failed. The production device keeps that record after
Drain() and, since #2727, CheckDecodeStatus() reads the accumulated bits
also then; the Reset() that follows clears them.

The executor already drains and checks the status before every Reset()
(Begin, Release, end of turn). The scripted device now behaves like the
production one so that this is actually exercised: Drain() completes the
running cycle but leaves its record in flight, the cycle can accumulate
error bits (fail_cycle), CheckDecodeStatus() reads them with a cycle in
flight or unconsumed, and the device logs the drain/status/reset order.

New tests: a failing look-ahead cycle of a stop-string turn surfaces when
the turn is rewound and fails the next request instead of being reset
away; the same for a canceled turn; a clean drained cycle lets the next
request run. Document the Drain() + CheckDecodeStatus() contract.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tianleiwu
Tianlei Wu (tianleiwu) force-pushed the tlwu/20261013/ga-b4-qwen38-engine-host branch from 1b03cb3 to cd0ddc7 Compare November 1, 2026 05:36
Tianlei Wu (tianleiwu) added a commit that referenced this pull request Nov 1, 2026
A cycle that the Engine ends inside its accepted prefix (turn or session
limit, stop string, EOS) was reported with min(proposed, accepted + 1)
evaluated drafts. With three accepted drafts but room for one token that
is two evaluated drafts for one accepted one, a 50% acceptance rate where
Engine::RecordSpeculativeCommit reports 100%.

Count the draft positions the turn examined up to its terminal token, like
Request::CommitAcceptedDraftsForTransaction does: every accepted draft it
consumed (an EOS draft is examined but not accepted) plus the rejected
position only when the turn reached the correction token.

The scripted device gains multi-draft cycles (SetDrafts: 1..3 drafts per
verify, any accepted prefix, reported through accepted_count/proposed_count
like the production device). Tests cover limit, session limit, stop string
and EOS truncation inside the accepted prefix, on the correction token and
on the bonus token; five of them fail with the old formula.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tianlei Wu (tianleiwu) added a commit that referenced this pull request Nov 1, 2026
A round that accepted some but not all of its drafts only recorded its
histogram entry: the zero/full classification skipped it and
partial_accept_rounds was never incremented, nor merged by AddStats.

Classify every round like Engine::RecordSpeculativeCommit does, by the
number of accepted drafts that were delivered (none: zero, all of the
proposed ones: full, otherwise partial), independently of whether a
correction token followed, and merge partial_accept_rounds in AddStats.
Bonus and correction tokens keep being counted on their own.

Tests cover a 0/1/2/3 accepted sequence of three-draft rounds (counters,
histogram, correction/bonus, acceptance rate), a partial round whose
correction does not fit the turn, a round truncated inside its accepted
drafts, an EOS first draft (zero accept) and the sum over requests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tianlei Wu (tianleiwu) added a commit that referenced this pull request Nov 1, 2026
…l total

The executor hands the prompt length of a turn to its own device
(ExpectPrefillTokens, after Reset() and before the first chunk) and never
publishes it through the process environment. Pin that with a scripted-device
test: two Engines on two owner threads, each with its own model and device, are
held at a rendezvous between the publication of the length and the first chunk
of every turn, and each device must keep its own total across the chunks of a
new turn, a continued turn that re-ingests the history after a Reset, and a
canceled turn, while ORT_QMOE_PREFILL_TOTAL_TOKENS stays untouched.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tianlei Wu (tianleiwu) added a commit that referenced this pull request Nov 1, 2026
…capability clamping, and prompt validation

- Use FixedSlotScheduler in CreateQwen38GraphDependencies and Scheduler::Create for continuous multi-slot caches, and require scheduler.SupportsIndependentSlotAdmission() for BackendAdmission::IndependentSlots.
- Clamp resolved and declared static max_request_length to model.context_length as well as cache capacity.
- Validate prompt token IDs against model.vocab_size at turn admission (and in Qwen38GraphSession::Begin) before mutating Request or device state.
- Ensure restartable canceled and rewound nonresident requests remain enrolled in StaticBatchScheduler and FixedSlotScheduler without duplicates.
- Use span.h in qwen38_graph_test_doubles.h and clarify scoped Qwen38OrtEnvScope publication in docs/paged_attention_engine.md.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/engine/engine.cpp Resolved
Comment thread src/engine/qwen38_graph_executor.cpp Resolved
Comment thread docs/paged_attention_engine.md Resolved
Tianlei Wu (tianleiwu) and others added 17 commits November 1, 2026 08:43
…path

Serves `qwen38_flash_graph` through the existing Engine seams so requests,
turns, cancellation, stop conditions and speculative statistics stay owned by
the Engine. The backend is a single-slot static path: one resident request,
greedy decoding, no prefix cache; further requests queue behind it in the
static batch scheduler.

- `Qwen38GraphCacheManager`: static-cache contract for one row.
- `Qwen38GraphExecutor`: a `ModelExecutor` that selects its own tokens. One Engine
  step is one prompt chunk (the device's dense prefill chunk size) or one whole
  decode cycle (the folded draft/verify/commit cycle, whose one or two committed
  tokens are published through `Request::CommitExternalTokens`). Device error bits
  are checked when a turn ends; a continued or rewound turn re-ingests its history
  through the same chunked prefill.
- `Qwen38GraphSession`: the platform- and CUDA-independent logic between the
  executor and a `Qwen38DecodeDevice`.
- Engine hooks, all defaulting to the previous behaviour for other models: turn
  validation with capability errors (sampling, repetition penalty, minimum token
  counts, seeds and oversized sessions are rejected up front), static-path stop
  strings and limits, speculative-statistics reporting, and
  `Engine::CreateDependencies` returning early for this model type before any
  IndexShare or MTP setup.
- `Request::CommitExternalTokens` applies EOS, length limits and stop strings per
  committed token.
- Tests (scripted device, no GPU): 34 executor/session cases covering dense chunk
  streaming and hand-off, cancel during prefill and decode, stop strings, EOS
  handling, continuation and rewind, all-rejected and all-accepted cycles, device
  errors, and capability errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…culative stats

- Propagate unchecked device decode status errors in Qwen38GraphSession::Release() and Begin() (and capture deferred teardown errors) so turns ending via stop strings, cancellation, rewind, or replacement still check device validation bits before Reset().

- Feed the committed RequestStepResult back to Qwen38GraphSession::OnStepCommitted() after Request::CommitExternalTokens so tokens_emitted, tokens_discarded, and accepted/bonus counts reflect actual committed tokens.

- Add unit tests and speculative-stats assertions for EOS, stop-string, and canceled turns.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…n Qwen38GraphSession

- Extend Qwen38StepResult::tokens to 4 tokens and update PendingStep/OnStepCommitted speculative stats for multi-draft S=2..4 cycles while preserving committed-token clipping.
- Export ORT_QMOE_PREFILL_TOTAL_TOKENS at the start of prompt prefill so QMoE memory lending can size the prefill budget.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…evice API

The graph model now sizes its prompt chunks from model.qwen38_graph.prefill.chunk
(model.qwen38_graph.prefill_chunk_tokens is removed) and receives the expected
prompt length through the device instead of the process environment.

- CreateQwen38GraphDependencies validates the chunk configuration with
  ValidateQwen38PrefillChunk and sizes the session from
  EffectiveQwen38PrefillChunkTokens; a device that names its own chunk still
  overrides it.
- Qwen38GraphSession no longer writes ORT_QMOE_PREFILL_TOTAL_TOKENS: it calls
  Qwen38DecodeDevice::ExpectPrefillTokens(total) after Reset() and before the
  first PrefillChunk() of every turn.
- The scripted device implements ExpectPrefillTokens (device-local, dropped by
  Reset()/BeginDecode()) and records the total every chunk saw; the tests use
  prefill.chunk, cover the default and invalid chunk configurations, the session's
  even rounding, and the announcement order.
- docs/paged_attention_engine.md describes the chunk sizing and the
  announcement.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The wasted look-ahead cycle of a stop-string or canceled turn is never
waited for, so its record stays unconsumed and only the device status bits
can tell that it failed. The production device keeps that record after
Drain() and, since #2727, CheckDecodeStatus() reads the accumulated bits
also then; the Reset() that follows clears them.

The executor already drains and checks the status before every Reset()
(Begin, Release, end of turn). The scripted device now behaves like the
production one so that this is actually exercised: Drain() completes the
running cycle but leaves its record in flight, the cycle can accumulate
error bits (fail_cycle), CheckDecodeStatus() reads them with a cycle in
flight or unconsumed, and the device logs the drain/status/reset order.

New tests: a failing look-ahead cycle of a stop-string turn surfaces when
the turn is rewound and fails the next request instead of being reset
away; the same for a canceled turn; a clean drained cycle lets the next
request run. Document the Drain() + CheckDecodeStatus() contract.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A cycle that the Engine ends inside its accepted prefix (turn or session
limit, stop string, EOS) was reported with min(proposed, accepted + 1)
evaluated drafts. With three accepted drafts but room for one token that
is two evaluated drafts for one accepted one, a 50% acceptance rate where
Engine::RecordSpeculativeCommit reports 100%.

Count the draft positions the turn examined up to its terminal token, like
Request::CommitAcceptedDraftsForTransaction does: every accepted draft it
consumed (an EOS draft is examined but not accepted) plus the rejected
position only when the turn reached the correction token.

The scripted device gains multi-draft cycles (SetDrafts: 1..3 drafts per
verify, any accepted prefix, reported through accepted_count/proposed_count
like the production device). Tests cover limit, session limit, stop string
and EOS truncation inside the accepted prefix, on the correction token and
on the bonus token; five of them fail with the old formula.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A round that accepted some but not all of its drafts only recorded its
histogram entry: the zero/full classification skipped it and
partial_accept_rounds was never incremented, nor merged by AddStats.

Classify every round like Engine::RecordSpeculativeCommit does, by the
number of accepted drafts that were delivered (none: zero, all of the
proposed ones: full, otherwise partial), independently of whether a
correction token followed, and merge partial_accept_rounds in AddStats.
Bonus and correction tokens keep being counted on their own.

Tests cover a 0/1/2/3 accepted sequence of three-draft rounds (counters,
histogram, correction/bonus, acceptance rate), a partial round whose
correction does not fit the turn, a round truncated inside its accepted
drafts, an EOS first draft (zero accept) and the sum over requests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…l total

The executor hands the prompt length of a turn to its own device
(ExpectPrefillTokens, after Reset() and before the first chunk) and never
publishes it through the process environment. Pin that with a scripted-device
test: two Engines on two owner threads, each with its own model and device, are
held at a rendezvous between the publication of the length and the first chunk
of every turn, and each device must keep its own total across the chunks of a
new turn, a continued turn that re-ingests the history after a Reset, and a
canceled turn, while ORT_QMOE_PREFILL_TOTAL_TOKENS stays untouched.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ot hosting

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
std::make_shared is nodiscard in the MSVC standard library, and EXPECT_THROW
discarded its result, so every Windows build (warnings as errors) failed on
test/cpp/engine/backend_factory_tests.cpp.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ScheduledRequests::StageExecutorTokens, executor_tokens_ and legacy_executor_tokens_ had no caller: the Qwen3.8 executor and the test doubles declare their step with BeginBackendStep and hand each row over with StageBackendRow, which validate the whole batch and carry a completion identity. The unvalidated, acknowledgement-less legacy branch could only be reached by a future caller that bypassed those checks. The SelectsOwnTokens documentation now names the protocol that is actually used.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…d limit-of-one edges

The design doc said that at Engine teardown the device is only drained. Qwen38GraphSession::~Qwen38GraphSession runs Release(), which also reads the decode status and, when anything fails, poisons the device context because nobody is left to report to. Also: include <exception> where std::exception_ptr is used, drop the empty stale banner in fixed_slot_scheduler.cpp and a stray '+ 0' in a disabled benchmark, and add two engine tests: a turn limit of one ends on the prompt prediction without starting a cycle, and a session that fills the whole KV capacity ends with the context-limit reason without overflowing the device ledger.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…so its turn can restart

Engine::BeginTurn restarts a turn that was canceled before the request made any progress, even after another request took the request's device state, and it enrolls only the first turn of a request. Both schedulers dropped a displaced request from their pool when its turn was complete, so the restarted turn was never scheduled and the Engine waited forever. A displaced request whose canceled turn is restartable now stays enrolled; any other displaced request is forgotten as before.

Tests: a canceled prefill request runs again after another request took the slot, and while the slot is still its own.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…er caught it

Release() reads the device decode status outside a step when it rewinds or retires a request. RunStatic's fatal handler does not see that failure, and the next Reset() erases the error bits, so a caller that caught the exception went on with a healthy-looking session. The failure now marks the context failed and is kept for the next step, the way ReleaseForTeardown() already keeps its own, and the caller still gets the exception.

Test: a device error found while rewinding poisons the context and fails the next step.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… cache cannot honor

A declared max_request_length of 0 means that the model search limit applies, which a cache that holds a finite number of positions cannot honor. The capability check compared only a non-zero declaration against the resolved length, so the contradiction went through and surfaced as an overflow during a long generation. A declared zero now fails the check whenever the resolved length is finite.

Test: a declared zero against a 64-position cache throws, and a declared 64 does not.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…capability clamping, and prompt validation

- Use FixedSlotScheduler in CreateQwen38GraphDependencies and Scheduler::Create for continuous multi-slot caches, and require scheduler.SupportsIndependentSlotAdmission() for BackendAdmission::IndependentSlots.
- Clamp resolved and declared static max_request_length to model.context_length as well as cache capacity.
- Validate prompt token IDs against model.vocab_size at turn admission (and in Qwen38GraphSession::Begin) before mutating Request or device state.
- Ensure restartable canceled and rewound nonresident requests remain enrolled in StaticBatchScheduler and FixedSlotScheduler without duplicates.
- Use span.h in qwen38_graph_test_doubles.h and clarify scoped Qwen38OrtEnvScope publication in docs/paged_attention_engine.md.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…e errors, and capability docs

DynamicBatchScheduler::AddRequest now ignores a request that is already in the pool. Engine::BeginTurn enrolls rewound and restarted-after-cancel requests again, and the dynamic pool only drops a request through RemoveRequest, so the request was planned twice in one step (a fatal planning inconsistency, and four existing rewind tests failed).

Qwen38GraphSession::Release rethrows the deferred device error without clearing it. Only a step consumes it, so retrying a failed rewind or retirement keeps failing instead of succeeding on a poisoned device.

Document that max_request_length is the smaller of the model context_length and KV capacity minus the reserved tail.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tianleiwu
Tianlei Wu (tianleiwu) force-pushed the tlwu/20261013/ga-b4-qwen38-engine-host branch from cd0ddc7 to 1786dd4 Compare November 1, 2026 09:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants